Версионирование API в Laravel-приложениях

Версионирование API – важная и зачастую сложная задача, у которой скорее всего нет какого-то универсального решения. Рассказываю об одном из возможных подходов в приложениях на базе Laravel.

Впрочем, сразу оговорюсь – такой способ можно реализовать не только в Laravel-приложениях, а ещё он может подойти не всем. Внедрение описанного метода может потребовать большого количества рефакторинга, и в таком случае быстрее и проще будет пойти (возможно) по пути копипастинга.

Зачем версионировать API

Зачастую версионирование API требуется приложениям, когда наступают следующие события:

  1. API является публичным;

  2. У API есть какое-то количество потребителей, для которых крайне важна обратная совместимость;

  3. В структуры входных и/или выходных данных нужно внести обратно несовместимые изменения.

Разумеется, это не единственные причины, когда может потребоваться версионирование, но по моему опыту эта необходимость появлялась именно в этом случае.

Как можно версионировать API

В большинстве статей по тематике версионирования чаще выделяется один способ – создавать копии контроллеров, запросов и обработчиков, и размещать их под префиксом новой версии (например, /v2). В целом, способ из этой статьи схож, однако, подход немного другой – я предлагаю (когда это возможно) версионировать только запросы и трансформеры (то есть то, что генерирует ваш ответ API).

Принцип

Принцип достаточно прост на словах – обработчикам запросов к API не нужно знать о том, под какой версией они запускаются. Если они достаточно универсальны, чтобы обрабатывать запросы из разных версий, всю необходимую информацию им нужно предоставлять версионированными запросами.

Возьмём пример обработчика, который регистрирует покупателя в интернет-магазине. К нему приходит запрос с номером телефона, страной, именем и паролем покупателя. Обработчик должен создать запись, отправить SMS с кодом подтверждения регистрации и отдать успешный ответ на запрос.

<?php  namespace App\Http\Controllers;  use App\Jobs\SendRegistrationCode; use App\Models\Customer; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request;  class CustomerRegistrationApiController {     public function store(Request $request): JsonResponse     {         $existedCustomer = Customer::where('phone', $request->input('phone_number'))->first();          if (!is_null($existedCustomer)) {             throw new \InvalidArgumentException('Покупатель уже зарегистрирован!');         }          $customer = Customer::create([             'phone' => $request->input('phone_number'),             'phone_country' => $request->input('phone_country') ?? 'RU',             'password' => bcrypt($request->input('password')),             'name' => $request->input('name'),             'surname' => $request->input('surname'),         ]);          dispatch(new SendRegistrationCode($customer));          return response()->json([             'created' => true,             'id' => $customer->id,         ]);     } }

Теперь предположим, что нам потребовалось переименовать некоторые поля. Вместо полей phone_number и phone_country мы хотим использовать объект phone с полями phone.number и phone.country, вместо name – first_name, а вместо surname – last_name. В ответе вместо поля id мы хотим отправлять только номер телефона.

Посмотрим, как это можно сделать разными способами.

Первый способ – проверка версии

Для начала можно собирать поля, проверяя текущую версию. Если версия не указана или v1, используем старые поля, если версия равна v2 – берём данные из новых. Предположим, что номер версии передаётся в route-параметре.

<?php  namespace App\Http\Controllers;  use App\Jobs\SendRegistrationCode; use App\Models\Customer; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request;  class CustomerRegistrationApiController {     public function store(Request $request, ?string $version = null): JsonResponse     {         $data = match (true) {             // Версия не указана или равна первой – используем старые поля.             is_null($version) || $version === 'v1' => [               'phone' => $request->input('phone_number'),               'phone_country' => $request->input('phone_country') ?? 'RU',               'password' => bcrypt($request->input('password')),               'name' => $request->input('name'),               'surname' => $request->input('surname'),             ],             $version === 'v2' => [               'phone' => $request->input('phone.number'),               'phone_country' => $request->input('phone.country') ?? 'RU',               'password' => bcrypt($request->input('password')),               'name' => $request->input('first_name'),               'surname' => $request->input('last_name'),             ],             default => throw new \InvalidArgumentException('Invalid data.'),         };          $existedCustomer = Customer::where('phone', $data['phone'])->first();          if (!is_null($existedCustomer)) {             throw new \InvalidArgumentException('Покупатель уже зарегистрирован!');         }          $customer = Customer::create($data);          dispatch(new SendRegistrationCode($customer));          $response = match (true) {             is_null($version) || $version === 'v1' => [               'created' => true,               'id' => $customer->id,             ],             $version === 'v2' => [                 'phone' => $customer->phone,             ],         };          return response()->json($response);     } }

Технически это будет работать, и если у вас небольшой API, возможно, этого будет достаточно. Однако, чем больше контроллеров/обработчиков придётся так переписывать, тем сложнее их будет поддерживать и вводить новые версии.

К тому же, вы скорее всего будете использовать валидацию запроса. При таком подходе во все обязательные поля придётся добавлять правила вида required_without:, чтобы убедиться, что хотя бы одно необходимое поле было передано (однако, это может пропустить запросы, где одни данные переданы в поле от версии v1, а другие – в поле от версии v2).

Второй способ – копия контроллера (и запроса)

Так же можно просто скопировать код контроллера в новый и использовать его для регистрации покупателей в версии v2. Так мы избавимся от проверок и изолируем код конкретной версии в конкретном классе.

<?php  namespace App\Http\Controllers;  use App\Jobs\SendRegistrationCode; use App\Models\Customer; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request;  class CustomerRegistrationApiControllerV2 {     public function store(Request $request): JsonResponse     {         $existedCustomer = Customer::where('phone', $request->input('phone.number'))->first();          if (!is_null($existedCustomer)) {             throw new \InvalidArgumentException('Покупатель уже зарегистрирован!');         }          $customer = Customer::create([             'phone' => $request->input('phone.number'),             'phone_country' => $request->input('phone.country') ?? 'RU',             'password' => bcrypt($request->input('password')),             'name' => $request->input('first_name'),             'surname' => $request->input('last_name'),         ]);          dispatch(new SendRegistrationCode($customer));          return response()->json([             'phone' => $customer->phone,         ]);     } }

Код снова приятно читать, не нужно ветвиться при добавлении новой версии – достаточно сделать ещё одну копию.

Но тут может возникнуть другая проблема. В новой версии могут добавиться новые необязательные поля, которые вряд ли будут нарушением обратной совместимости для предыдущей версии, поэтому их можно скопировать и в v1.

Теперь вам нужно проверять, были ли портированы новые поля в старые версии, любые изменения в новой версии (по-хорошему) должны так же портироваться в предыдущую версию, чтобы сохранить единую логику работы.

Третий способ – использование интерфейсов

Именно этим способом я хочу поделиться.

Скорее всего вы знаете, что сервис контейнер Laravel позволяет связать интерфейс и имплементацию, после чего достаточно запрашивать нужный интерфейс, а не конкретный класс.

Знаете ли вы, что это работает и с запросами? Если у вас есть класс запроса (наследованный от Illuminate\Http\FormRequest) с правилами валидации и авторизации и он реализует интерфейс, вы можете внедрить этот интерфейс в метод контроллера и Laravel выполнит проверку авторизации и валидацию данных точно так же, как если бы вы внедрили класс запроса.

Такая возможность позволяет нам сделать два разных запроса для каждой версии API, описать в них свои правила валидации и реализовать общий интерфейс с геттерами данных. Затем запросить этот интерфейс в метод контроллера и убрать все проверки версии (и, соответственно, не копировать контроллеры).

В дополнение к этому вы можете создать интерфейс трансформера ответа API и возвращать необходимую структуру из реализаций для конкретных версий.

<?php  namespace App\Http\Controllers;  use App\Interfaces\Requests\RegisterCustomerRequestInterface; use App\Interfaces\Transformers\CustomerRegisteredTransformerInterface; use App\Jobs\SendRegistrationCode; use App\Models\Customer; use Illuminate\Http\JsonResponse;  class CustomerRegistrationApiController {     public function store(         RegisterCustomerRequestInterface $request,         CustomerRegisteredTransformerInterface $transformer,     ): JsonResponse {         $existedCustomer = Customer::where('phone', $request->getPhoneNumber())->first();          if (!is_null($existedCustomer)) {             throw new \InvalidArgumentException('Покупатель уже зарегистрирован!');         }          $customer = Customer::create([             'phone' => $request->getPhoneNumber(),             'phone_country' => $request->getPhoneCountry(),             'password' => bcrypt($request->getCustomerPassword()),             'name' => $request->getFirstName(),             'surname' => $request->getLastName(),         ]);          dispatch(new SendRegistrationCode($customer));          return response()->json($transformer->toArray($customer));     } }

С этим